hello,JS:08 setTimeout和任务队列

前言:

之前写过一次被不小心删掉了,幸好思路和参考资料还在,所以赶快写下来。里面涉及了一点点dom事件的操作(但不影响学习)。


一、什么是定时器

JS提供定时执行代码功能,叫做定时器(timer),主要由etTimeout()setInterval()这两个函数来完成。setTimeout()setInterval()是windows的两个全局属性。

二、setTimeout()

setTimeout函数用来指定某个函数或某段代码,在多少毫秒之后执行。它返回一个整数,表示定时器的编号,以后可以用来取消这个定时器。

1
var timerId = setTimeout(func|code, delay)//括号里代表(函数|代码字符串,延迟的时间毫秒数)

先写一个函数,在通过setTimeout调用函数,如:

1
2
3
4
5
function f(){
console.log(2)
}

setTimeout(f,1000) //表示在1s之后执行这个函数

或者

1
setTimeout(function (){console.log(2)},1000)//通过使用并调用执行该匿名函数

如:

使用setTimeout,连续几次之后,发现一个现象,返回了类似于有序的编号整数。这是由于setTimeout本身执行的时候,里面的函数返回值可认为返回的是一个定时器的id(或编号),当我们执行setTimeout,浏览器则会创建一个延时器(即一个对象),该延时器的返回则是一个编号。那么,这样的话,我们可以通过编号找到相对应的定时器
续上面例子,如:

1
2
3
4
5
6
7
8
9
10
var timer = setTimeout(function(){
console.log('wangxiaoqin')
},10000)
--> undefined
timer
--> 419
wangxiaoqin //若不做任何操作,1s后返回这个字符串

clearTimeout(timer) //表示还未执行,该定时器就被取消操作
clearTimeout(422) //表示提前取消编号为XXX的定时器

三、setInterval()

用法与setTimeout一样,区别在于setInterval指定某个任务每隔一段时间就执行一次,也就是无限次的定时执行。即间隔执行任务。

1、如每隔1s执行一次

1
2
3
4
var i = 1
var timer = setInterval(function() {
console.log(i++);
}, 1000)

2、可用来做一个时钟:

1
2
3
4
var i = 1
var timer = setInterval(function(){
console.log(new Date());
},1000)

四、clearTimeout()clearInterval()

setTimeout和setInterval函数,都返回一个表示计数器编号的整数值,将该整数传入clearTimeout和clearInterval函数,就可以取消对应的定时器。

1
2
3
4
5
var id1 = setTimeout(f,1000);
var id2 = setInterval(f,1000);

clearTimeout(id1);
clearInterval(id2);

五、从setTimeout(f,0)引发的关于JS运行环境的探究

我们先看这样一个例子:

1
2
3
4
5
6
setTimeout(function() {
console.log(1);
},0);
console.log(2)
-->2
1 //0s后,返回

为什么会先返回2,再返回1呢?(先留着疑问)

1
2
3
4
5
6
7
8
9
10
11
12
var isOk = true   //第1:首先声明变量isOk,默认为true
setTimeout(function(){
console.log(1)
isOk = false
},1000) //异步回调:需要1秒之后,才能将 isOk 设为 false(1s后才执行,所以暂不执行)

while(isOk){
console.log(2)
} //第2:先进行while循环判断,isOk是否为true,如果是,那么就是返回console(2)的结果


//第4:当过了1s后,代码执行,isOK = false,就会停止执行

是不是稍微有点明白?
简单的例子里其实涉及到了JS运行中的很多方面,让我们详细看看

关键词:JS运行环境、事件循环、异步回调

1、单线程模型

这里截取阮一峰老师的JavaScript的教程中的单线程描述

单线程模型指的是,JavaScript只在一个线程上运行。也就是说,JavaScript 同时只能执行一个任务,其他任务都必须在后面排队等待。

JavaScript 只在一个线程上运行,不代表JavaScript引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合。

JavaScript 之所以采用单线程,而不是多线程,跟历史有关系。JavaScript 从诞生起就是单线程,原因是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果,对于一种网页脚本语言来说,这就太复杂了。为了避免复杂性,JavaScript一开始就是单线程,已成为这门语言的核心特征,将来也不会改变。

2、线程

涉及到单、多线程,这里截取李佳怡专栏文章中关于线程的描述

(1)定义
浏览器的内核是多线程的,它们在内核控制下相互配合以保持同步,一个浏览器通常由以下常驻线程组成:GUI渲染线程,javascript引擎线程,浏览器事件触发线程,定时触发器线程,异步 http 请求线程。

(2)常驻线程

  • GUI 渲染线程:负责渲染浏览器界面HTML元素,当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。在 Javascript 引擎运行脚本期间,GUI渲染线程都是处于挂起状态的,也就是说被”冻结”。即 GUI 渲染线程与 JS 引擎是互斥的,当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到 JS 引擎空闲时立即被执行。

  • javascript 引擎线程:也可以称为 JS 内核,主要负责处理 Javascript 脚本程序,例如 V8 引擎。Javascript 引擎线程理所当然是负责解析 Javascript 脚本,运行代码。浏览器无论什么时候都只有一个 JS 线程在运行 JS 程序。

  • 浏览器事件触发线程:当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。这些事件可以是当前执行的代码块如定时任务、也可来自浏览器内核的其他线程如鼠标点击、AJAX异步请求等,但由于JS的单线程关系所有这些事件都得排队等待 JS 引擎处理。

  • 定时触发器线程:浏览器定时计数器并不是由 JavaScript 引擎计数的, 因为 javaScript 引擎是单线程的,如果处于阻塞线程状态就会影响记计时的准确, 因此通过单独线程来计时并触发定时是更为合理的方案。

  • 异步 http 请求线程:在XMLHttpRequest在连接后是通过浏览器新开一个线程请求,将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript 引擎的处理队列中等待处理。

3、了解一下JS的V8运行环境


主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在”任务队列”中加入各种事件(clickloaddone)。只要栈中的代码执行完毕,主线程就会去读取”任务队列”,依次执行那些事件所对应的回调函数。
注:假设这段话暂时看不明白,暂时放掉,先了解下面的其他知识,完毕之后再回看这段话,就能明白。

说说图中的几个关键名词

(1)堆(heap)
对象被分配在一个堆中,即用以表示一个大部分非结构化的内存区域。

(2)栈(stack)
函数调用形成了一个栈帧。

而通过使用js 调用栈(call stack)则能更清晰地了解单线程的执行过程。

js 调用栈(call stack):
函数被调用时,就会被加入到调用栈顶部,执行结束之后,就会从调用栈顶部移除该函数,这种数据结构的关键在于【后进先出】,即 LIFO(last-in,first-out)

第一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function multiply(a,b){
return a*b
}

function square(n){
return multiply(n,n)
}

function printSquare(n){
var squared = square(n);
console.log(squared);
}

printSquare(4)

//一个将两个数字相乘的函数multiply,一个调用了前者的平方函数square,
//一个打印函数printSquare,它调用了square,然后将结果用console.log打印出来
//然后最后我们调用了printSquare

//运行
//调用栈(callback),基本上是一个记录当前程序所在位置的数据结构。如果当前进入了某个函数,
//这个函数就会被放在栈里面。如果当前离开了某个函数,这个函数就会被弹出栈外,这是栈所做的事。

//如果你运行这个文件,将会有一个类似main的函数,指代文件本身,首先,把它放进栈中。
//接着,我们从上到下查看了声明的函数,看到了最后是printSquare,知道了它被调用了,
//那么我们把它推进栈里;它调用了square,所以也把square推进栈里;square也调用了mulitiply,
//同样把mulitiply推进栈中,最后,我们得到了mulitiply的返回值

//那么这之后,我们把multiply弹出栈,然后square也得到了返回值,再把square弹出栈,
//最后到了printSquare,它调用了console.log,到这里已经没有返回值。我们到了函数的最后部分,
//然后我们完成了。

第二个例子:

1
2
3
4
5
6
7
8
9
function f(b) {
var a = 12;
return a + b + 35;
}
function g(x) {
var m = 4;
return f(m * x);
}
g(21);

调用 g 函数 的时候,创建了第一个 堆( Heap ) 栈(stack) 帧 ,包含了 g 的参数和局部变量。当g调用f的时候,第二个堆栈帧就被创建、并置于第一个 堆栈帧 之上,包含了 f 的参数和局部变量。当 f 返回时,最上层的 堆栈帧 就出栈了(剩下g函数调用的堆栈帧)。当g返回的时候,栈就空了。

第三个例子:

1
2
3
4
5
6
7
function test() {
setTimeout(function() {
alert(1)
},1000);
alert(2);
}
test();

在执行函数 test 的时候,test先入栈,如果不给alert(1)加setTimeout,那么 alert(1)第2个入栈,最后是alert(2)。但现在给alert(1)加上 setTimeout后,alert(1)就被加入到了一个新的堆栈中等待,并1s后执行,因此实际的执行结果就是先 alert(2),再 alert(1)

(3)队列(queue)
一个 JavaScript 运行时包含了一个待处理的消息队列。每一个消息都有一个为了处理这个消息相关联的函数。

任务队列(消息队列):

任务(消息)队列是一个先进先出的队列,它里面存放着各种任务(消息)

A、同步任务VS异步任务

1
2
3
4
5
6
console.log('Hi')
setTimeout(function(){
console.log('There')
},1000)

console.log('wangxiaoqin')

  • 同步函数:如果在函数A返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。如:

    1
    console.log('Hi’);   /*函数返回时,就看到了预期的效果:在控制台打印了一个字符串*/
  • 异步函数:即如果在函数A返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。如:

    1
    2
    setTimeout(fn, 1000);
    /*setTimeout是异步过程的发起函数,fn是回调函数*/
  • 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。

  • 异步任务:主线程发起一个异步请求(即执行异步函数),相应的工作线程(浏览器事件触发线程、异步http请求线程等)接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,将完成消息放到任务(消息)队列,主线程通过事件循环过程去取任务(消息),然后执行一定的动作(调用回调函数)。看此图可视化描述:

B、事件循环(Event loop)
事件循环,指主线程重复从任务(消息)队列中取任务(消息)、执行的过程。取一个任务(消息)并执行的过程叫做一次循环。
即:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
} //如果当前没有任何消息queue.waitForMessage 会等待同步消息到达

事件循环中有事件两个字的原因:任务(消息)队列中的每条消息实际上都对应着一个事件——dom事件。如:

1
2
3
4
var button = document.getElement('#btn');
button.addEventListener('click',function(e) {
console.log();
});

从异步过程的角度看,addEventListener 函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数。事件触发时,表示异步任务完成,会将事件监听器函数封装成一条消息放到消息队列中,等待主线程执行。那么添加的这个任务(消息)事实上就是任务注册异步任务时添加的回调函数。如果 一个异步函数没有回调,那么它就不会放到任务(消息)队列里。

总结:主线程在执行完当前循环中的所有代码后,就会到任务(消息)队列取出一条消息,并执行它。到此为止,就完成了工作线程对主线程的通知,回调函数也就得到了执行。如果一开始主线程就没有提供回调函数,工作线程就没必要通知主线程,从而也没必要往消息队列放消息。如图:

了解一下工作线程(即异步 http 请求线程,即 Ajax 线程)是如何工作:

4、再来看setTimeout(f,0)所带来的零延迟与事件循环、任务队列的联系

setTimeout和setInterval的运行机制是,将指定的代码移出本次执行,等到下一轮Event Loop时,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就等到再下一轮Event Loop时重新判断。这意味着,setTimeout指定的代码,必须等到本次执行的所有代码都执行完,才会执行。

什么意思呢?

setTimeout的作用是,将代码推迟到指定时间执行,如果指定时间为0,即setTimeout(f,0),那么不会立刻执行。这里则涉及到了零延迟。

零延迟 (Zero delay) 并不是意味着回调会立即执行。在零延迟调用 setTimeout 时,其并不是过了给定的时间间隔后就马上执行回调函数。其等待的时间基于队列里正在等待的消息数量。也就是说,setTimeout()只是将事件插入了任务队列,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证回调函数一定会在setTimeout()指定的时间执行。

1
2
3
4
5
6
setTimeout(function() {
console.log(1);
},0);
console.log(2)
-->2
1 //0s后,返回

现在我们知道为什么返回结果是2,1。因为只有在执行完主线程的所有代码之后,主线程空了,才会去任务队列中取任务执行回调函数,去执行回调函数。
总结: setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在”任务队列”的尾部添加一个事件,因此要等到主线程把同步任务和”任务队列”现有的事件都处理完,才会得到执行。

1
2
3
4
5
for(var i=0; i<10; i++){
setTimeout(function(){
console.log(i)
}, 1000)
}

执行结果为:

相当于for(var i=0; i<10; i++)这个同步代码执行完之后,i的值变为10 。此时(1s后),执行回调函数,在同步任务中创建了10个定时器均在1s中之后执行,则返回了10

由此看来,在某种程度上,我们可以利用setTimeout(fn,0)的特性,修正浏览器的任务顺序。

参考、学习并感谢:

1.MDN:并发模型与事件循环

2.阮一峰JavaScript参考教程:异步操作概述

3.李佳怡专栏:【 js 基础 】 setTimeout(fn, 0)的作用

-------------本文结束感谢您的阅读-------------